TypeScriptのLambda関数をTerraformだけでデプロイする
以前、「GolangのLambda関数をTerraformだけでデプロイする」というエントリを書きました。
今回はこれのTypeScript版をやっていきたいと思います。
動機としてはGolangの際と同じです。すでにTerraformでたくさんのリソースをプロビジョニングしているプロジェクトで、Lambda関数一つ追加するためだけにcdkやslsを使うよりかはTerraformにまとめてしまいたいと考えました。
やること
以下AWS公式ドキュメントにAWS CLIとesbuildでTypeScript Lambda関数をデプロイする例が載っています。これをベースにterraform apply
だけでデプロイできる構成を作成します。
つまりTerraformの中でLambda関数のデプロイに加えて以下も行ないます。
- パッケージのインストール
- ts → jsへのトランスパイル
- デプロイメントパッケージ(Zipファイル)の作成
全体像
コードの全体像は以下です。
. ├── lambdas │ └── helloworld │ ├── .gitignore │ ├── dist │ ├── index.ts │ ├── package-lock.json │ └── package.json ├── .gitignore ├── .terraform-version ├── .terraform.lock.hcl ├── lambda.tf ├── locals.tf ├── main.tf └── providers.tf
処理の流れは以下です。
- Lambda関数ごとに
/lambdas
以下にディレクトリを作成(今回はhelloworld
のみ) - 上記ディレクトリ以下でコードを書く
/lambda/helloworld/dist/
以下にトランスパイル後のjsファイルを作成する/lambda/helloworld/dist/
以下にデプロイメントパッケージ(Zipファイル)を作成する- デプロイメントパッケージをS3にアップロードする
- アップロードしたデプロイメントパッケージを指定してLambda関数をデプロイ
3〜6までが terraform apply
でできます。かつ2回目以降の実行ではコードに変更があったときだけこれら(3〜6)の処理が走ります。
動作環境
- MacBook Pro (13-inch, M1, 2020)
- macOs Big Sur v11.6.5
- Terraform
- v1.2.3
- aws provider
- v4.19.0
- null provider
- v3.1.1
- 以下コマンドが使える
- aws cli
- openssl
- zip
- npm
GitHub
以下で中身を解説していきます。(Golang版のエントリと内容が重複する点が多々あります。)
Lambda関数コード
コードの中身自体は今回は特に重要ではないので、前述のAWS公式ドキュメントに載っていたものを使っています。
import { Context, APIGatewayProxyResult, APIGatewayEvent } from 'aws-lambda'; export const handler = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => { console.log(`Event: ${JSON.stringify(event, null, 2)}`); console.log(`Context: ${JSON.stringify(context, null, 2)}`); return { statusCode: 200, body: JSON.stringify({ message: 'hello world', }), }; };
Resource: aws_lambda_function
上記コードを使うLambda関数をデプロイするリソースです。
resource "aws_lambda_function" "helloworld" { function_name = "typescript-sample-helloworld" s3_bucket = aws_s3_bucket.lambda_assets.bucket s3_key = data.aws_s3_object.package.key role = aws_iam_role.iam_for_lambda.arn handler = "index.handler" source_code_hash = data.aws_s3_object.package_hash.body runtime = "nodejs16.x" timeout = "10" }
デプロイメントパッケージの指定
aws_lambda_functionではデプロイメントパッケージの指定方法が2種類、ローカル上に存在するものを使う方法と、S3にアップされているものを使う方法があります。今回は後者を採用しました。
Golangの際はローカルの方法が安定しなかったのでS3の方法を採用した経緯があります。詳しくはGolang版のエントリをご覧ください。今回はローカル版の検証は行わず実績のあるS3版のみをやってみましたが、おそらく今回もローカル版だとGolangの際と同じ問題が発生するはずです。
source_code_hash
このattributeは関数の再デプロイのトリガーに使われます。この値が更新されると再デプロイされます。
Must be set to a base64-encoded SHA256 hash of the package file
という要件があります。
ローカルのデプロイメントパッケージを使う場合は filebase64sha256("file.zip")
とか base64sha256(file("file.zip"))
といった方法が使えます。
また、ローカルのデプロイメントパッケージを使い、さらにzip化までTerraform管理内にする場合は Data Sources: archive_file が使えて、このAttributes Referenceにoutput_base64sha256
というのがあるので、 source_code_hash = data.archive_file.deploy_package.output_base64sha256
みたいな書き方が使えます。
しかしながら、もう一方の方法、S3にアップされているファイルを使う方法の場合の指定方法が調べても中々見つからず、苦労しました。。最終的にbase64-encoded SHA256 hashの結果だけを格納したファイルを作成して、それをS3にアップし参照するという方法に落ち着きました。この点は後述します。
デプロイメントパッケージアップロード用S3バケット
前述したとおりデプロイメントパッケージをS3にアップロードする必要があるので、それ用のS3バケットをプロビジョニングします。セキュリティ面の最低限の設定を加えています。
resource "aws_s3_bucket" "lambda_assets" {} resource "aws_s3_bucket_acl" "lambda_assets" { bucket = aws_s3_bucket.lambda_assets.bucket acl = "private" } resource "aws_s3_bucket_server_side_encryption_configuration" "lambda_assets" { bucket = aws_s3_bucket.lambda_assets.bucket rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } resource "aws_s3_bucket_public_access_block" "lambda_assets" { bucket = aws_s3_bucket.lambda_assets.bucket block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true }
デプロイメントパッケージを参照する Datasource: aws_s3_bucket_objects
前述のaws_lambda_function.helloworld.s3_key
で参照されているdata sourceです。
depends_on = [null_resource.lambda_build]
を指定しているのがポイントです。後述しますがこのnull_resource.lambda_build
内でこのdata sourceで参照しているS3オブジェクトを作成しています。ですので、null_resource.lambda_build
の実行がこのdata sourceの参照より先に行われることを保証するためにdepends_on
が必要なのです。またこのdepends_on
のおかげでnull_resource.lambda_build
が再実行される度にその後に参照が行われます。
data "aws_s3_object" "package" { depends_on = [null_resource.lambda_build] bucket = aws_s3_bucket.lambda_assets.bucket key = local.helloworld_function_package_s3_key }
パッケージのインストール、トランスパイル、zip化、S3アップロード、全部null_resourceでやる
null_resource
はちょっと特殊なリソースで、簡単にいうと指定したコマンドを実行するリソースです。 provisioner "local-exec"
というブロック内で書いたコマンドはローカルで実行され、provisioner "remote-exec"
ブロック内に書いたコマンドは外部サーバー上で実行されます。(接続設定が別途必要です)
local-exec
今回以下のように3つprovisioner "local-exec"
ブロックを定義しており、デプロイメントパッケージ(zipファイル)をS3バケットにアップロードするまでの作業をすべて行なっています。(local-execブロックは残り2つ、つまり計5つあるのですが、残りの2つについては別項にて後述します。)
provisioner "local-exec" { command = "cd ${local.helloworld_function_dir_local_path} && npm install" } provisioner "local-exec" { command = "cd ${local.helloworld_function_dir_local_path} && npm run build" } provisioner "local-exec" { command = "aws s3 cp ${local.helloworld_function_package_local_path} s3://${aws_s3_bucket.lambda_assets.bucket}/${local.helloworld_function_package_s3_key}" }
npm run buildの中身は以下です。(前述のAWS公式ドキュメントに書かれているものと同じです)
{ "scripts": { "prebuild": "rm -rf dist/", "build": "esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js", "postbuild": "cd dist && zip -r index.zip index.js*" } }
prebuild
とpostbuild
はそれぞれ build
が実行される前後に実行されます。(※ 参考:npm-scripts:pre・postプレフィックスを利用して、スクリプト実行前後に別のスクリプトも実行させる方法 - NxWorld)
AWS認証設定についての注意点
最後のS3へのアップロード処理でaws cliを使っています。もしTerraform aws providerの認証設定を例えば以下のようにprovider "aws"
の中でやっている場合、
provider "aws" { shared_config_files = ["/Users/tf_user/.aws/conf"] shared_credentials_files = ["/Users/tf_user/.aws/creds"] profile = "customprofile" }
このクレデンシャル設定は上記 null_resource内でやっているaws cliでは使われませんのでご注意ください。provider "aws"
で定義した認証設定はaws providerの各resource / data sourceとやり取りするために使われるものであり、null provider管轄のnull_resourceとは無関係です。
AWS_PROFILE
やAWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
といった環境変数でprovider "aws"
外で認証設定している場合は、その認証がaws providerでもnull_resource内のaws cliコマンドでも使われるでしょう。
triggers
null_resource内で定義されたコマンドが実行されるタイミングは以下2つです。
- 初回、リソース作成時
- triggers attribute以下のいずれかの値が更新された際
今回triggers attributeは以下のように設定しています。
triggers = { code_diff = join("", [ for file in fileset(local.helloworld_function_dir_local_path, "{*.ts, package*.json}") : filebase64("${local.helloworld_function_dir_local_path}/${file}") ]) }
./lambdas/helloworld/
以下に置いたtsファイルとpackage.json、package-lock.jsonからハッシュを生成しています。これらのファイルに何かしら変更があった場合このハッシュ値が変わるので、コマンドが再実行つまりパッケージのインストール、トランスパイル、zip化、S3アップロードの処理が走ります。
aws_lambda_function.source_code_hash
provisioner "local-exec"
のまだ説明していない残りの2つは、aws_lambda_function.source_code_hash
値を更新して、Lambda関数再デプロイを走らせるための前処理です。
provisioner "local-exec" { command = "openssl dgst -sha256 -binary ${local.helloworld_function_package_local_path} | openssl enc -base64 | tr -d \"\n\" > ${local.helloworld_function_package_base64sha256_local_path}" } provisioner "local-exec" { command = "aws s3 cp ${local.helloworld_function_package_base64sha256_local_path} s3://${aws_s3_bucket.lambda_assets.bucket}/${local.helloworld_function_package_base64sha256_s3_key} --content-type \"text/plain\"" }
source_code_hash
値は「base64-encoded SHA256 hash of the package file」である必要があるとResource: aws_lambda_functionの項でご説明しました。1つ目のprovisioner "local-exec"
でこのハッシュ値を作成してファイル出力しています。そして2つ目でS3にアップロードしています。
アップロードしたファイルは以下Data Sourceで参照します。これもdepends_on = [null_resource.lambda_build]
を入れることで、必ず S3にファイルがアップロードされた後に参照されるようにしています。
data "aws_s3_object" "package_hash" { depends_on = [null_resource.lambda_build] bucket = aws_s3_bucket.lambda_assets.bucket key = local.helloworld_function_package_base64sha256_s3_key }
そして、aws_lambda_functionにて上記Data Sourceを参照します。aws_s3_object Data Sourceにはbodyというfieldがあるので、オブジェクトの中身を使えるというわけです。
resource "aws_lambda_function" "helloworld" { function_name = "typescript-sample-helloworld" s3_bucket = aws_s3_bucket.lambda_assets.bucket s3_key = data.aws_s3_object.package.key role = aws_iam_role.iam_for_lambda.arn handler = "index.handler" source_code_hash = data.aws_s3_object.package_hash.body runtime = "nodejs16.x" timeout = "10" }
ポイントとしては、null_resource内でaws s3 cp
コマンドでS3にハッシュ文字列を格納したファイルをアップロードする際に、Content Typeを指定している点です。 aws_s3_object Data Sourceでbody値を参照するには、該当オブジェクトのContent Typeがhuman-readableなもの(text/*
もしくは application/json
) である必要があるためです。
Note: The content of an object (
body
field) is available only for objects which have a human-readableContent-Type
(text/*
andapplication/json
). This is to prevent printing unsafe characters and potentially downloading large amount of data which would be thrown away in favour of metadata.
指定なしでアップロードするとContent Typeがbinary/octet-stream
になってしまい、bodyフィールドはnullを返していました。